Skip to content

C++ 构造与析构中调用虚函数:多态失效与纯虚调用崩溃 ​

C++ 允许在构造函数和析构函数中调用虚函数,编译器不会报错,但这个行为几乎总是与写代码时的直觉不符:虚分派不会到达派生类的覆盖版本,多态失效;如果调到的是纯虚函数,程序直接终止。本文解释这一行为的底层机制、两类典型故障场景与替代方案,适合了解虚函数基本用法的读者。

机制:vptr 随构造与析构逐级推进 ​

多态类型对象的虚函数分派依赖对象头部的虚表指针(vptr)。在对象的整个生命周期里,vptr 不是一成不变的:

  • 构造阶段:基类构造函数执行时,vptr 先指向基类虚表;基类构造完成后,vptr 被改为指向派生类虚表,派生类构造函数才开始执行。多级继承则逐级推进。
  • 析构阶段:顺序相反。派生类析构函数先执行,此时 vptr 仍指向派生类虚表;派生类部分析构完毕后,vptr 回退为基类虚表,基类析构函数才开始执行。

语言层面,C++ 标准([class.cdtor])明确规定:在构造或析构期间调用虚函数,解析目标是当前正在构造或析构的类自身的版本,而不是最终派生类的覆盖版本。这个规定与 vptr 的推进过程互为表里——进入基类析构函数的那一刻,对象的"身份"已经退化为基类,即使这个对象动态类型本来是派生类。

示例:多态失效 ​

cpp
#include <iostream>

class Base {
public:
    virtual ~Base() {
        func(); // 析构函数中调用虚函数
    }
    virtual void func() {
        std::cout << "Base::func\n";
    }
};

class Derived : public Base {
public:
    ~Derived() override = default;
    void func() override {
        std::cout << "Derived::func\n";
    }
};

int main() {
    Base* p = new Derived;
    delete p;
    // 输出: Base::func(而不是 Derived::func)
}

执行 delete p 时的实际顺序:

  1. 调用 ~Derived(),派生类部分析构完毕;
  2. vptr 回退为 Base 的虚表;
  3. ~Base() 中的 func() 经虚表分派,命中 Base::func。

派生类的覆盖版本根本不会被调用。如果基类作者的本意是"析构时让派生类做各自的清理",这个设计从一开始就不成立。构造函数同理:基类构造函数里的虚调用只会命中基类版本,因为派生类部分尚不存在。

崩溃场景:调用到纯虚函数 ​

如果虚函数在基类中是纯虚函数,基类析构函数(或构造函数)中调用它,主流实现上会得到:

text
pure virtual method called
terminate called without an active exception

程序随即终止。原因是此时 vptr 指向基类虚表,而纯虚函数在虚表中的槽位填的是占位函数(GCC 下为 __cxa_pure_virtual),不存在可用的实现。标准层面这属于未定义行为,不应依赖具体的报错文案,但"程序无法继续"这一点是一致的。

真正的未定义行为:间接多态 ​

需要区分清楚的是:直接的虚调用不会分派到派生类版本,因此"基类析构中调用虚函数、派生类实现访问了已析构成员"这条路径本身不会发生。未定义行为主要出现在间接多态上:

  • 派生类构造时把自己注册进了某个回调、观察者或信号列表;
  • 对象销毁过程中(例如基类析构阶段)回调被触发,执行了派生类的代码;
  • 此时派生类的成员已经析构,访问它们就是访问悬空对象——未定义行为,典型表现是无效读取或崩溃。

如果注册方没有在派生类析构中及时注销,这类问题的崩溃栈往往指向回调入口,远离真正的错误源头,排查成本高。规避方式是在派生类析构函数里显式注销回调,而不是依赖基类析构中的虚调用去"通知"派生类。

替代方案 ​

目的 做法
析构时执行派生类各自的清理 在 delete 之前显式调用虚函数(如 p->cleanup()),再销毁对象
析构时通知派生类 "二段析构":公开的非虚 close()/cleanup() 先做事再销毁,析构函数只做兜底
工厂或资源管理场景 把资源释放设计成独立的虚 release(),由外层管理者在析构前调用

这些做法的共同点是:把"需要完整多态能力的调用"移到对象还完整的阶段,析构函数只处理自己这一层确定存在的资源。

《Effective C++》条款 9 对此的总结是:绝不在构造和析构过程中调用虚函数(Never call virtual functions during construction or destruction)。

小结 ​

  • 构造/析构期间的虚调用只解析到当前类自身的版本,vptr 的推进机制决定了派生类覆盖版本不可能被分派到;
  • 调用到纯虚函数槽位时程序直接终止;
  • 真正的未定义行为来自间接多态(回调、观察者)访问已析构的派生类成员,规避方式是让派生类在析构中自行注销;
  • 需要"析构前做事"时,用显式的二段析构代替析构函数里的虚调用。

参考 ​

最近更新

基于 VitePress 构建